package cron

import "time"

// The Schedule describes a job's duty cycle.
type Schedule interface {
	Next(time.Time) time.Time
}

// bounds provides a range of acceptable values (plus a map of name to value).
type bounds struct {
	min, max uint
	names    map[string]uint
}

// The bounds for each field.
var (
	seconds = bounds{0, 59, nil}
	minutes = bounds{0, 59, nil}
	hours   = bounds{0, 23, nil}
	dom     = bounds{1, 31, nil}
	months  = bounds{1, 12, map[string]uint{
		"jan": 0x1, "feb": 0x2, "mar": 0x3, "apr": 0x4, "may": 0x5, "jun": 0x6,
		"jul": 0x7, "aug": 0x8, "sep": 0x9, "oct": 0xa, "nov": 0xb, "dec": 0xc,
	}}
	dow = bounds{0, 6, map[string]uint{
		"sun": 0x0, "mon": 0x1, "tue": 0x2, "wed": 0x3, "thu": 0x4, "fri": 0x5, "sat": 0x6,
	}}
)

// SpecSchedule specifies a duty cycle (to the second granularity), based on a
// traditional crontab specification. It is computed initially and stored as bit sets.
type SpecSchedule struct {
	Second, Minute, Hour, Dom, Month, Dow uint64
}

const (
	// Set the top bit if a star was included in the expression.
	star = 1 << 63
)

// Next returns the next time this schedule is activated, greater than the given
// time. If no time can be found to satisfy the schedule, return the zero time.
func (s *SpecSchedule) Next(t time.Time) time.Time {
	// General approach:
	// For Month, Day, Hour, Minute, Second:
	// Check if the time value matches. If yes, continue to the next field.
	// If the field doesn't match the schedule, then increment the field until it matches.

	// Start at the earliest possible time
	t = t.Add(1*time.Second - time.Duration(t.Nanosecond())*time.Nanosecond)

	// This flag indicates whether a field has been incremented.
	added := false

WRAP:
	// Find the first applicable month.
	// If it's this month, then do nothing.
	for 1<<uint(t.Month())&s.Month == 0 {
		// If we have to add a month, reset the other parts to 0.
		if !added {
			added = true
			t = time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, t.Location())
		}
		t = t.AddDate(0, 1, 0)

		// Wrapped around.
		if t.Month() == time.January {
			goto WRAP
		}
	}

	// Now get a day in that month.
	for !dayMatches(s, t) {
		if !added {
			added = true
			t = time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
		}
		t = t.AddDate(0, 0, 1)

		if t.Day() == 1 {
			goto WRAP
		}
	}

	for 1<<uint(t.Hour())&s.Hour == 0 {
		if !added {
			added = true
			t = t.Truncate(time.Hour)
		}
		t = t.Add(1 * time.Hour)

		if t.Hour() == 0 {
			goto WRAP
		}
	}

	for 1<<uint(t.Minute())&s.Minute == 0 {
		if !added {
			added = true
			t = t.Truncate(time.Minute)
		}
		t = t.Add(1 * time.Minute)

		if t.Minute() == 0 {
			goto WRAP
		}
	}

	for 1<<uint(t.Second())&s.Second == 0 {
		if !added {
			added = true
			t = t.Truncate(time.Second)
		}
		t = t.Add(1 * time.Second)

		if t.Second() == 0 {
			goto WRAP
		}
	}

	return t
}

// dayMatches returns true if the schedule's day-of-week and day-of-month
// restrictions are satisfied by the given time.
func dayMatches(s *SpecSchedule, t time.Time) bool {
	var (
		domMatch bool = 1<<uint(t.Day())&s.Dom > 0
		dowMatch bool = 1<<uint(t.Weekday())&s.Dow > 0
	)

	if s.Dom&star > 0 || s.Dow&star > 0 {
		return domMatch && dowMatch
	}
	return domMatch || dowMatch
}

// SimpleDelaySchedule represents a simple non recurring duration.
type AbsoluteSchedule struct {
	Date time.Time
}

func (schedule AbsoluteSchedule) Next(t time.Time) time.Time {
	if schedule.Date.After(t) {
		return schedule.Date
	}
	return t.AddDate(10, 0, 0)
}

// Just store the given time for this schedule.
func At(date time.Time) AbsoluteSchedule {
	return AbsoluteSchedule{Date: date}
}

// DurationSchedule represents a simple recurring duty cycle, e.g. "Every 5 minutes".
// It does not support jobs more frequent than once a second.
type DurationSchedule struct {
	dur time.Duration
}

func (this DurationSchedule) Next(t time.Time) time.Time {
	return t.Add(this.dur - time.Duration(t.Nanosecond())*time.Nanosecond)
}

// Every returns a crontab Schedule that activates once every duration.
// Delays of less than a second are not supported (will round up to 1 second).
// Any fields less than a Second are truncated.
func Every(duration time.Duration) DurationSchedule {
	if duration < time.Second {
		duration = time.Second
	}
	return DurationSchedule{dur: duration - time.Duration(duration.Nanoseconds())%time.Second}
}
